| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693 |
- 'use client';
- import { use, useEffect, useMemo, useState, useRef, useCallback } from 'react';
- import { createPortal } from 'react-dom';
- import * as signalR from '@microsoft/signalr';
- import { Pause, Play, SkipForward, Bell, BellOff, Volume2, Image as ImageIcon, MoreVertical, X, AlertTriangle, Wifi, WifiOff, ChevronUp, ChevronDown } from 'lucide-react';
- import { DonationAlertData, DonationRemoteState, AlertQueueItem, AlertStatusKey, RemoteCrewItem, RemoteCrewMembersResponse, RemoteCrewMemberItem } from '@/types/donation';
- import { fetchApi } from '@/lib/utils/client';
- import './style.scss';
- type Props = {
- params: Promise<{ widgetToken: string }>;
- };
- const STATUS_LABEL: Record<AlertStatusKey, string> = {
- queued: '대기',
- playing: '재생 중',
- delivered: '완료',
- skipped: '건너뜀',
- ignored: '무시',
- failed: '실패'
- };
- type PlayingMeta = { startedAt: number; durationSec: number };
- /**
- * 둥근 시계형 카운트다운 — 보라색 호가 시계방향으로 차오르며 남은 초 표시.
- * 매 250ms 다시 렌더링 (성능 negligible).
- */
- function TimerRing({ meta }: { meta: PlayingMeta }) {
- const [now, setNow] = useState<number>(() => Date.now());
- useEffect(() => {
- const id = setInterval(() => setNow(Date.now()), 250);
- return () => clearInterval(id);
- }, []);
- const elapsedSec = (now - meta.startedAt) / 1000;
- const remainingSec = Math.max(0, meta.durationSec - elapsedSec);
- const progress = Math.min(1, elapsedSec / meta.durationSec);
- const r = 12;
- const c = 2 * Math.PI * r;
- const offset = c * (1 - progress);
- return (
- <span className="dpot-remote__timer-ring" aria-label={`남은 시간 ${Math.ceil(remainingSec)}초`}>
- <svg width="30" height="30" viewBox="0 0 30 30">
- <circle cx="15" cy="15" r={r} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="2.5" />
- <circle
- cx="15" cy="15" r={r}
- fill="none"
- stroke="#7c3aed"
- strokeWidth="2.5"
- strokeLinecap="round"
- strokeDasharray={c}
- strokeDashoffset={offset}
- transform="rotate(-90 15 15)"
- style={{ transition: 'stroke-dashoffset 0.25s linear' }}
- />
- </svg>
- <span className="dpot-remote__timer-num">{Math.ceil(remainingSec)}</span>
- </span>
- );
- }
- type TabKey = 'all'|'queued'|'ignored'|'failed'|'skipped'|'delivered';
- const TAB_DEFS: { key: TabKey; label: string }[] = [
- { key: 'all', label: '전체' },
- { key: 'queued', label: '대기' },
- { key: 'ignored', label: '무시' },
- { key: 'failed', label: '실패' },
- { key: 'skipped', label: '건너뜀' },
- { key: 'delivered', label: '완료' }
- ];
- export default function RemotePage({ params }: Props)
- {
- const { widgetToken } = use(params);
- const apiBase = '/api/donation/remote';
- const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
- const [connected, setConnected] = useState(false);
- const [state, setState] = useState<DonationRemoteState>({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false });
- const [queue, setQueue] = useState<AlertQueueItem[]>([]);
- const [openMenuID, setOpenMenuID] = useState<number|null>(null);
- const [menuPos, setMenuPos] = useState<{ top: number; right: number }|null>(null);
- const [activeTab, setActiveTab] = useState<TabKey>('all');
- const [isPopup, setIsPopup] = useState(false);
- const [playingMeta, setPlayingMeta] = useState<Record<number, PlayingMeta>>({});
- // 통합 테스트 후원 form (selectedCrewID null 이면 일반, 값 있으면 크루 후원)
- const [testName, setTestName] = useState('');
- const [testAmount, setTestAmount] = useState<number>(1000);
- const [testMessage, setTestMessage] = useState('');
- const [isTestMode, setIsTestMode] = useState(true);
- const [testSubmitting, setTestSubmitting] = useState(false);
- const [crews, setCrews] = useState<RemoteCrewItem[]>([]);
- const [crewMembers, setCrewMembers] = useState<RemoteCrewMemberItem[]>([]);
- const [selectedCrewID, setSelectedCrewID] = useState<number|null>(null);
- const [selectedMemberID, setSelectedMemberID] = useState<number|null>(null);
- const connectionRef = useRef<signalR.HubConnection|null>(null);
- useEffect(() => {
- setIsPopup(typeof window !== 'undefined' && !!window.opener);
- }, []);
- // 메뉴 외부 클릭 시 닫기
- useEffect(() => {
- if (openMenuID === null) {
- return;
- }
- const handler = (e: MouseEvent) => {
- const target = e.target as HTMLElement;
- if (!target.closest('.dpot-remote__menu-dropdown') && !target.closest('.dpot-remote__menu-btn')) {
- setOpenMenuID(null);
- setMenuPos(null);
- }
- };
- document.addEventListener('click', handler);
- return () => document.removeEventListener('click', handler);
- }, [openMenuID]);
- const toggleMenu = (alertID: number, e: React.MouseEvent<HTMLButtonElement>) => {
- if (openMenuID === alertID) {
- setOpenMenuID(null);
- setMenuPos(null);
- return;
- }
- const rect = e.currentTarget.getBoundingClientRect();
- setMenuPos({
- top: rect.bottom + 4,
- right: window.innerWidth - rect.right
- });
- setOpenMenuID(alertID);
- };
- const closeMenu = () => {
- setOpenMenuID(null);
- setMenuPos(null);
- };
- const loadState = useCallback(async () => {
- try {
- const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${widgetToken}`, { silent: true });
- if (res.data) {
- setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
- setQueue(res.data.queue || []);
- }
- } catch {}
- }, [apiBase, widgetToken]);
- const loadCrews = useCallback(async () => {
- try {
- const res = await fetchApi<{ crews: RemoteCrewItem[] }>(`${apiBase}/crews/${widgetToken}`, { silent: true });
- if (res.data) {
- setCrews(res.data.crews);
- }
- } catch {}
- }, [apiBase, widgetToken]);
- const loadCrewMembers = useCallback(async (crewID: number) => {
- try {
- const res = await fetchApi<RemoteCrewMembersResponse>(`${apiBase}/crews/${widgetToken}/${crewID}/members`, { silent: true });
- if (res.data) {
- setCrewMembers(res.data.members);
- setSelectedMemberID(null);
- }
- } catch {}
- }, [apiBase, widgetToken]);
- const connectHub = useCallback(() => {
- const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
- conn.on('ReceiveAlert', (data: DonationAlertData) => {
- setQueue(prev => {
- const existing = prev.find(q => q.alertID === data.alertID);
- if (existing) {
- return prev.map(q => q.alertID === data.alertID ? { ...q, status: 'queued' as AlertStatusKey } : q);
- }
- const item: AlertQueueItem = {
- alertID: data.alertID,
- donationID: data.donationID,
- status: 'queued',
- sponsorMemberID: data.sponsorMemberID,
- sendName: data.sendName,
- amount: data.amount,
- message: data.message,
- isTest: data.isTest,
- createdAt: data.createdAt
- };
- return [item, ...prev];
- });
- });
- conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
- conn.on('ReceiveSkip', () => {
- setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
- });
- conn.on('ReceiveQueueReorder', (data: { swap?: number[] }) => {
- if (!data?.swap || data.swap.length !== 2) {
- return;
- }
- const [a, b] = data.swap;
- setQueue(prev => {
- const idxA = prev.findIndex(q => q.alertID === a);
- const idxB = prev.findIndex(q => q.alertID === b);
- if (idxA < 0 || idxB < 0) {
- return prev;
- }
- const next = [...prev];
- [next[idxA], next[idxB]] = [next[idxB], next[idxA]];
- return next;
- });
- });
- conn.on('ReceiveAlertStatusUpdate', (update: { alertID: number; status: AlertStatusKey; displayDurationSec?: number|null; startedAt?: string|null }) => {
- setQueue(prev => prev.map(q => q.alertID === update.alertID ? { ...q, status: update.status } : q));
- // playing 진입 시 타이머 ring 시작용 메타 저장
- if (update.status === 'playing' && update.displayDurationSec && update.displayDurationSec > 0) {
- const started = update.startedAt ? new Date(update.startedAt).getTime() : Date.now();
- setPlayingMeta(prev => ({ ...prev, [update.alertID]: { startedAt: started, durationSec: update.displayDurationSec! } }));
- } else if (update.status !== 'playing') {
- // playing 종료 → 메타 제거 (memory leak 방지)
- setPlayingMeta(prev => {
- if (!(update.alertID in prev)) {
- return prev;
- }
- const next = { ...prev };
- delete next[update.alertID];
- return next;
- });
- }
- });
- conn.start().then(() => {
- conn.invoke('JoinChannel', widgetToken);
- setConnected(true);
- }).catch(() => {});
- conn.onclose(() => setConnected(false));
- conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); });
- connectionRef.current = conn;
- }, [hubUrl, widgetToken]);
- useEffect(() => {
- loadState();
- loadCrews();
- connectHub();
- return () => { connectionRef.current?.stop(); };
- }, [loadState, loadCrews, connectHub]);
- useEffect(() => {
- if (selectedCrewID) {
- loadCrewMembers(selectedCrewID);
- } else {
- setCrewMembers([]);
- setSelectedMemberID(null);
- }
- }, [selectedCrewID, loadCrewMembers]);
- const togglePause = async () => {
- const next = { ...state, isPaused: !state.isPaused };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const toggleAccepting = async () => {
- const next = { ...state, isAccepting: !state.isAccepting };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const toggleAudioOnly = async () => {
- const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const toggleVideoOnly = async () => {
- const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
- setState(next);
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
- };
- const skipCurrent = async () => {
- await fetchApi(`${apiBase}/skip/${widgetToken}`, { method: 'POST', silent: true });
- };
- const ignoreAlert = async (alertID: number) => {
- await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true });
- setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q));
- closeMenu();
- };
- // 일시정지 중일 때만, queued 상태 인접 알림과 swap
- const swapAlert = async (alertID: number, direction: 'up'|'down') => {
- const queuedItems = filtered.filter(q => q.status === 'queued');
- const idx = queuedItems.findIndex(q => q.alertID === alertID);
- if (idx < 0) {
- return;
- }
- const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
- if (targetIdx < 0 || targetIdx >= queuedItems.length) {
- return;
- }
- const partner = queuedItems[targetIdx];
- await fetchApi(`${apiBase}/reorder`, {
- method: 'POST',
- body: { widgetToken, alertIDA: alertID, alertIDB: partner.alertID },
- silent: true
- });
- // 백엔드가 broadcast 한 ReceiveQueueReorder 가 도착하면 화면 갱신됨
- };
- const resendAlert = async (alertID: number) => {
- await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true });
- setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q));
- closeMenu();
- };
- const playNow = async (alertID: number) => {
- closeMenu();
- // status 변경은 SignalR ReceiveAlertStatusUpdate에 맡김 (위젯이 재생 시작 → 'playing' broadcast)
- // 직접 setQueue 하면 race condition으로 'playing'을 덮어씀
- await fetchApi(`${apiBase}/play-now/${alertID}`, { method: 'POST', silent: true });
- };
- const markDelivered = async (alertID: number) => {
- closeMenu();
- // Playing 상태에서 stuck된 알림 강제 완료 처리 (위젯 종료/네트워크 단절 등)
- // status 변경은 SignalR broadcast로 동기화
- await fetchApi(`${apiBase}/mark-delivered/${alertID}`, { method: 'POST', silent: true });
- };
- const submitTestDonate = async () => {
- if (!testName.trim()) {
- alert('별명을 입력하세요.');
- return;
- }
- if (testAmount < 1000) {
- alert('최소 후원 금액은 1,000원입니다.');
- return;
- }
- if (selectedCrewID && (selectedMemberID === null || selectedMemberID <= 0)) {
- alert('크루원을 선택하세요.');
- return;
- }
- if (!isTestMode) {
- const isCrew = !!selectedCrewID;
- const msg = isCrew
- ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있으면 리더보드에 합산됩니다. 계속하시겠습니까?'
- : '영구 후원으로 기록됩니다. 본인 채널 통계에 반영됩니다. 계속하시겠습니까?';
- const ok = window.confirm(msg);
- if (!ok) {
- return;
- }
- }
- setTestSubmitting(true);
- try {
- await fetchApi(`${apiBase}/test-donate`, {
- method: 'POST',
- body: {
- widgetToken,
- sendName: testName.trim(),
- amount: testAmount,
- message: testMessage.trim() || null,
- crewMemberID: selectedCrewID ? selectedMemberID : null,
- isTest: isTestMode
- },
- silent: false
- });
- setTestMessage('');
- } catch (e) {
- console.error('test-donate failed:', e);
- } finally {
- setTestSubmitting(false);
- }
- };
- const counts = useMemo(() => ({
- all: queue.length,
- queued: queue.filter(q => q.status === 'queued' || q.status === 'playing').length,
- ignored: queue.filter(q => q.status === 'ignored').length,
- failed: queue.filter(q => q.status === 'failed').length,
- skipped: queue.filter(q => q.status === 'skipped').length,
- delivered: queue.filter(q => q.status === 'delivered').length
- }), [queue]);
- const filtered = useMemo(() => {
- if (activeTab === 'all') {
- return queue;
- }
- if (activeTab === 'queued') {
- return queue.filter(q => q.status === 'queued' || q.status === 'playing');
- }
- return queue.filter(q => q.status === activeTab);
- }, [activeTab, queue]);
- const formatTime = (dateStr: string) => {
- const d = new Date(dateStr);
- return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
- };
- return (
- <div className="dpot-remote">
- {/* 상단 상태 배너 */}
- <div className={`dpot-remote__banner${state.isPaused ? ' dpot-remote__banner--paused' : ' dpot-remote__banner--playing'}`} role="status">
- {state.isPaused ? (
- <><Pause size={14} aria-hidden="true" /><span>현재 알림 일시정지 중입니다</span></>
- ) : (
- <><Play size={14} aria-hidden="true" /><span>현재 알림 재생 중입니다</span></>
- )}
- </div>
- {/* 헤더 */}
- <header className="dpot-remote__header">
- <div className="dpot-remote__brand">
- <span className="dpot-remote__brand-logo">DPOT</span>
- <span className="dpot-remote__brand-title">리모콘</span>
- </div>
- <div className="dpot-remote__header-actions">
- <span className={`dpot-remote__connection${connected ? ' dpot-remote__connection--on' : ' dpot-remote__connection--off'}`}>
- {connected ? <Wifi size={12} aria-hidden="true" /> : <WifiOff size={12} aria-hidden="true" />}
- <span>{connected ? '연결됨' : '연결 끊김'}</span>
- </span>
- {isPopup && (
- <button type="button" className="dpot-remote__close-btn" onClick={() => window.close()} aria-label="닫기">
- <X size={16} aria-hidden="true" />
- </button>
- )}
- </div>
- </header>
- {/* 운영 토글 (음성만/영상만/후원수신) */}
- <div className="dpot-remote__sub-controls">
- <button type="button" className={`dpot-remote__sub-btn${state.isAccepting ? '' : ' dpot-remote__sub-btn--off'}`} onClick={toggleAccepting}>
- {state.isAccepting ? <Bell size={14} aria-hidden="true" /> : <BellOff size={14} aria-hidden="true" />}
- <span>{state.isAccepting ? '후원 받는 중' : '후원 안 받음'}</span>
- </button>
- <button type="button" className={`dpot-remote__sub-btn${state.isAudioOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleAudioOnly}>
- <Volume2 size={14} aria-hidden="true" />
- <span>음성만</span>
- </button>
- <button type="button" className={`dpot-remote__sub-btn${state.isVideoOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleVideoOnly}>
- <ImageIcon size={14} aria-hidden="true" />
- <span>영상만</span>
- </button>
- </div>
- {/* 통합 테스트 후원 form (크루 미선택=일반, 선택=크루) */}
- <fieldset className="dpot-remote__test-form">
- <legend className="dpot-remote__test-form-legend">테스트 후원</legend>
- <div className="dpot-remote__test-row">
- <select
- className="dpot-remote__select"
- value={selectedCrewID ?? ''}
- onChange={e => setSelectedCrewID(e.target.value ? parseInt(e.target.value, 10) : null)}
- >
- <option value="">크루 없음 (일반 후원)</option>
- {crews.map(c => (
- <option key={c.id} value={c.id}>크루 · {c.name} ({c.memberCount}명)</option>
- ))}
- </select>
- <select
- className="dpot-remote__select"
- value={selectedMemberID ?? ''}
- onChange={e => setSelectedMemberID(e.target.value ? parseInt(e.target.value, 10) : null)}
- disabled={!selectedCrewID || crewMembers.length === 0}
- >
- <option value="">{selectedCrewID ? '크루원 선택' : '— 크루원 —'}</option>
- {crewMembers.map(m => (
- <option key={m.id} value={m.id}>{m.nickname}{m.role ? ` (${m.role})` : ''}</option>
- ))}
- </select>
- </div>
- <div className="dpot-remote__test-row">
- <input
- type="text"
- className="dpot-remote__input"
- placeholder="별명"
- value={testName}
- onChange={e => setTestName(e.target.value)}
- maxLength={30}
- />
- <input
- type="number"
- className="dpot-remote__input dpot-remote__input--amount"
- placeholder="금액"
- value={testAmount > 0 ? testAmount : ''}
- onChange={e => setTestAmount(parseInt(e.target.value || '0', 10))}
- min={1000}
- step={1000}
- />
- </div>
- <textarea
- className="dpot-remote__textarea"
- placeholder="메시지 (선택)"
- value={testMessage}
- onChange={e => setTestMessage(e.target.value)}
- rows={2}
- maxLength={200}
- />
- <div className="dpot-remote__test-form-footer">
- <label className="dpot-remote__checkbox">
- <input
- type="checkbox"
- checked={isTestMode}
- onChange={e => setIsTestMode(e.target.checked)}
- />
- <span>테스트 모드 (위젯에만 표시, DB 통계 미반영)</span>
- </label>
- <button
- type="button"
- className="dpot-remote__test-submit"
- onClick={submitTestDonate}
- disabled={testSubmitting}
- >
- {testSubmitting ? '전송 중...' : '후원하기'}
- </button>
- </div>
- {!isTestMode && (
- <p className="dpot-remote__warn" role="alert">
- <AlertTriangle size={14} aria-hidden="true" />
- <span>
- {selectedCrewID
- ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있을 경우 리더보드에 합산됩니다.'
- : '영구 후원으로 기록됩니다. 통계에 반영됩니다.'}
- </span>
- </p>
- )}
- </fieldset>
- {/* 6개 탭 */}
- <div className="dpot-remote__tabs" role="tablist">
- {TAB_DEFS.map(t => (
- <button
- key={t.key}
- type="button"
- role="tab"
- aria-selected={activeTab === t.key}
- className={`dpot-remote__tab${activeTab === t.key ? ' dpot-remote__tab--active' : ''}`}
- onClick={() => setActiveTab(t.key)}
- >
- <span className="dpot-remote__tab-label">{t.label}</span>
- <span className="dpot-remote__tab-count">{counts[t.key]}</span>
- </button>
- ))}
- </div>
- {/* 후원 알림 listing */}
- <div className="dpot-remote__list-wrap">
- <div className="dpot-remote__list-header" role="row">
- <span className="dpot-remote__col dpot-remote__col--reorder">순서</span>
- <span className="dpot-remote__col dpot-remote__col--name">별명</span>
- <span className="dpot-remote__col dpot-remote__col--amount">금액</span>
- <span className="dpot-remote__col dpot-remote__col--msg">메시지</span>
- <span className="dpot-remote__col dpot-remote__col--time">시간</span>
- <span className="dpot-remote__col dpot-remote__col--status">상태</span>
- <span className="dpot-remote__col dpot-remote__col--menu">기능</span>
- </div>
- <div className="dpot-remote__list">
- {filtered.length === 0 && <div className="dpot-remote__empty">후원 알림이 없습니다</div>}
- {filtered.map(item => {
- // queued 항목 중 reorder 가능한 위치 판정
- const queuedItems = filtered.filter(q => q.status === 'queued');
- const qIdx = queuedItems.findIndex(q => q.alertID === item.alertID);
- const canMoveUp = item.status === 'queued' && qIdx > 0;
- const canMoveDown = item.status === 'queued' && qIdx >= 0 && qIdx < queuedItems.length - 1;
- return (
- <div key={item.alertID} className={`dpot-remote__row dpot-remote__row--${item.status}`} role="row">
- <span className="dpot-remote__col dpot-remote__col--reorder">
- {item.status === 'queued' ? (
- <>
- <button
- type="button"
- className="dpot-remote__reorder-btn"
- onClick={() => swapAlert(item.alertID, 'up')}
- disabled={!canMoveUp}
- aria-label="위로"
- title="위로"
- >
- <ChevronUp size={14} aria-hidden="true" />
- </button>
- <button
- type="button"
- className="dpot-remote__reorder-btn"
- onClick={() => swapAlert(item.alertID, 'down')}
- disabled={!canMoveDown}
- aria-label="아래로"
- title="아래로"
- >
- <ChevronDown size={14} aria-hidden="true" />
- </button>
- </>
- ) : null}
- </span>
- <span className="dpot-remote__col dpot-remote__col--name" title={item.sendName}>
- {item.isTest && <span className="dpot-remote__test-badge">TEST</span>}
- {item.sendName}
- </span>
- <span className="dpot-remote__col dpot-remote__col--amount">{item.amount.toLocaleString()}원</span>
- <span className="dpot-remote__col dpot-remote__col--msg" title={item.message ?? ''}>{item.message ?? '—'}</span>
- <span className="dpot-remote__col dpot-remote__col--time">{formatTime(item.createdAt)}</span>
- <span className={`dpot-remote__col dpot-remote__col--status dpot-remote__status-badge dpot-remote__status-badge--${item.status}`}>
- {item.status === 'playing' && playingMeta[item.alertID]
- ? <TimerRing meta={playingMeta[item.alertID]} />
- : (STATUS_LABEL[item.status] ?? item.status)}
- </span>
- <span className="dpot-remote__col dpot-remote__col--menu">
- <button
- type="button"
- className="dpot-remote__menu-btn"
- onClick={(e) => toggleMenu(item.alertID, e)}
- aria-label="메뉴 열기"
- aria-expanded={openMenuID === item.alertID}
- >
- <MoreVertical size={16} aria-hidden="true" />
- </button>
- </span>
- </div>
- );
- })}
- </div>
- </div>
- {/* 하단 메인 컨트롤 */}
- <div className="dpot-remote__main-controls">
- <button
- type="button"
- className={`dpot-remote__main-btn dpot-remote__main-btn--pause${state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
- onClick={() => { if (!state.isPaused) togglePause(); }}
- disabled={state.isPaused}
- >
- <Pause size={18} aria-hidden="true" />
- <span>일시정지</span>
- </button>
- <button
- type="button"
- className="dpot-remote__main-btn dpot-remote__main-btn--skip"
- onClick={skipCurrent}
- >
- <SkipForward size={18} aria-hidden="true" />
- <span>건너뛰기</span>
- </button>
- <button
- type="button"
- className={`dpot-remote__main-btn dpot-remote__main-btn--play${!state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
- onClick={() => { if (state.isPaused) togglePause(); }}
- disabled={!state.isPaused}
- >
- <Play size={18} aria-hidden="true" />
- <span>재생</span>
- </button>
- </div>
- {/* 메뉴 dropdown — Portal로 body에 렌더해서 list overflow 클립 회피 */}
- {openMenuID !== null && menuPos && typeof document !== 'undefined' && (() => {
- const activeItem = queue.find(q => q.alertID === openMenuID);
- if (!activeItem) {
- return null;
- }
- return createPortal(
- <div
- className="dpot-remote__menu-dropdown"
- role="menu"
- style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
- >
- {activeItem.status === 'queued' && (
- <>
- <button type="button" className="dpot-remote__menu-item" onClick={() => playNow(activeItem.alertID)}>즉시 재생</button>
- <button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
- </>
- )}
- {activeItem.status === 'playing' && (
- <button type="button" className="dpot-remote__menu-item" onClick={() => markDelivered(activeItem.alertID)}>완료 처리</button>
- )}
- {(activeItem.status === 'failed' || activeItem.status === 'skipped' || activeItem.status === 'ignored' || activeItem.status === 'delivered') && (
- <button type="button" className="dpot-remote__menu-item" onClick={() => resendAlert(activeItem.alertID)}>재전송</button>
- )}
- {activeItem.status === 'skipped' && (
- <button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
- )}
- </div>,
- document.body
- );
- })()}
- </div>
- );
- }
|